문단 클릭 = 시작 위치 선택 · 재생은 우하단 버튼/P (재생 중 클릭 = 점프)

8장. RAG 성능을 높이는 LLM 파인튜닝

출처 — 브라이스 유·조경아·박수진·김재웅, 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(프리렉, 2025), 8장 (pp. 453~524). 원문 PDF rag_master_final_v11_260910.pdf (2026-09-10 판)

RAFT 논문의 핵심 아이디어(네거티브 샘플·생각의 사슬·출처 인용)를 학습 데이터에 반영해 직접 데이터셋을 구성하고, 런팟 GPU 클라우드에서 Qwen2 모델을 LoRA로 파인튜닝해 RAG 응답 품질을 끌어올린다.

학습 목표

이 장을 끝내면 다음을 할 수 있다. - RAFT 논문이 제안한 네거티브 샘플과 생각의 사슬 기법을 설명한다 - 답변 없음 데이터와 출처 인용이 RAG 파인튜닝에서 맡는 역할을 구분한다 - RAG 학습 데이터셋의 다섯 가지 유형(mrc_question 등)을 비교한다 - 런팟에서 GPU 서버를 대여해 파인튜닝 실습 환경을 구성한다 - Qwen 챗 템플릿에 맞춰 데이터를 가공하고 LoRA로 모델을 파인튜닝한다 - 정수 인코딩과 labels 마스킹(-100)이 학습 대상을 어떻게 좁히는지 구현한다

전체 흐름도

RAFT 논문 개념 정리                    RAG 학습 데이터셋 준비                런팟에서 LoRA 파인튜닝 실습
┌───────────────────┐          ┌─────────────────────────┐      ┌────────────────────────────┐
│ §1 RAFT 논문       │          │ §3 RAG 학습 데이터셋       │      │ §4 로컬 LLM Qwen 파인튜닝     │
│  ├ 네거티브 샘플    │          │  ├ mrc_question           │      │  ① 런팟 GPU 서버 대여        │
│  └ 생각의 사슬(CoT)│ ───────▶│  ├ mrc_question_with_1_4  │ ───▶│  ② OpenAI 형식 전처리       │
└───────────────────┘          │  ├ synthetic_question      │      │  ③ Qwen 챗 템플릿 적용      │
         │                     │  ├ paraphrased_question    │      │  ④ LoRA 설정(r·alpha 등)   │
         ▼                     │  └ no_answer               │      │  ⑤ SFTConfig 학습 설정     │
┌───────────────────┐          └─────────────────────────┘      │  ⑥ 정수 인코딩(labels -100)│
│ §2 성능 향상 팁     │                     │                      │  ⑦ SFTTrainer 학습·저장    │
│  ├ 답변 없음 데이터 │ ────────────────────┴─────────────────────▶│  ⑧ 학습 전후 비교 테스트    │
│  └ 출처 인용        │                                           │  ⑨ GPU 서버 종료          │
└───────────────────┘                                            └────────────────────────────┘

0. 용어 사전

참고 — 위쪽 3개는 이 장을 읽기 전에 알아야 하는 선행 용어다. 낯설면 2장·7장을 먼저 확인한다.

한글 용어 원문 영문명 의미
검색 증강 생성 Retrieval-Augmented Generation, RAG (선행) 질문과 관련된 문서를 검색해 그 내용을 근거로 답변을 생성하는 방식(2장 §1). 이 장 전체가 이 방식의 언어 모델을 파인튜닝하는 내용이다. 본문 §1~§4
벡터 데이터베이스 Vector Database (선행) 문서를 숫자 벡터로 바꿔 저장하고 유사도로 검색하는 저장소(2장 §4). 이 장의 학습 데이터도 이런 검색 단계를 거쳐 만들어졌다는 전제를 깔고 있다. 본문 §3
생각의 사슬 Chain of Thought, CoT (선행) 결론을 바로 내지 않고 "이유 → 답" 순서로 추론을 드러내는 프롬프트 기법(7장 §1). RAFT는 이 방식을 학습 데이터 형식에 그대로 반영한다. 본문 §1.2
RAFT Retrieval Augmented Fine Tuning 검색 증강 생성 상황에 맞춰 언어 모델을 파인튜닝하는 방법을 제안한 버클리대 논문(2024). 네거티브 샘플과 생각의 사슬을 학습 데이터 구성에 반영한다. 본문 §1
파인튜닝 Fine-tuning 이미 학습된 모델에 사용자의 데이터를 추가로 학습시켜 그 데이터에 맞게 성능을 조정하는 과정. 본문 전체
네거티브 샘플 Negative Sample 검색은 됐지만 질문의 정답 근거가 들어 있지 않은 문서. 본문 §1.1
포지티브 샘플 Positive Sample 질문에 대한 정답 근거가 실제로 들어 있는 문서. 본문 §1.1
할루시네이션 Hallucination 근거 없이 모델이 사실처럼 지어내는 답변. 본문 §2.1
답변 없음 데이터 No-Answer Data 검색 결과에 정답이 없을 때 "찾을 수 없다"고 답하도록 만든 학습 데이터. 본문 §2.1
출처 인용 Citation 답변 문장마다 근거가 된 문서 번호를 남기는 것(예: [[ref3]]). 본문 §2.2
런팟 RunPod 시간 단위로 과금되는 GPU 클라우드 서비스. 본문 §4.1
로라 LoRA, Low-Rank Adaptation 기존 모델 가중치는 그대로 두고 작은 저차원 행렬만 추가로 학습하는 파인튜닝 방식. 본문 §4.4
챗 템플릿 Chat Template 모델이 사전학습 때 익힌 대화 형식(역할 구분 토큰 등). 본문 §4.3
정수 인코딩 Encoding 텍스트를 모델이 처리할 수 있는 정수 시퀀스로 바꾸는 과정. 본문 §4.6
SFTConfig Supervised Fine-Tuning Config 학습률·배치 크기 등 지도학습 파인튜닝 전반의 설정을 정의하는 도구. 본문 §4.5
SFTTrainer Supervised Fine-Tuning Trainer 모델·데이터셋·설정을 받아 실제 학습을 수행하는 클래스. 본문 §4.5
labels의 -100 Label Masking 손실 계산에서 제외할 토큰을 표시하는 값. 시스템·사용자 프롬프트 구간에 쓰인다. 본문 §4.6
체크포인트 Checkpoint 학습 도중 정해진 스텝마다 저장되는 모델 스냅샷. 본문 §4.5·§4.7

1. RAFT 논문 살펴보기

RAG의 성능을 높이기 위한 데이터셋 제작이나 학습 방법을 고민할 때 가장 먼저 참고하기 좋은 자료가 미국 버클리 대학에서 작성한 논문 "RAFT: Adapting Language Model to Domain Specific RAG"(arXiv:2403.10131)다. RAFT는 Retrieval Augmented Fine Tuning의 약자이며, 이 절에서는 RAG를 위한 파인튜닝에 참고할 수 있는 이 논문의 핵심 개념 두 가지 — 네거티브 샘플과 생각의 사슬 — 를 정리한다.

1.1 네거티브 샘플

인공지능 분야에서 네거티브 샘플은 모델이 찾고자 하는 대상과 반대되는 예시를 뜻한다. 스팸 메일 분류기를 예로 들면, 분류기가 찾아야 할 스팸 메일이 포지티브 샘플이고 정상 메일이 네거티브 샘플이다.

검색 증강 생성에서도 같은 구도가 나타난다. 원문의 예시(그림 8-1)를 보면, '한글을 창제한 조선의 왕은?'이라는 질문에 '장영실', '이순신', '이방원', '세종대왕' 네 개의 문서가 검색되었다고 가정한다. 답변에 필요한 근거는 '세종대왕' 문서에만 있고, 나머지 세 문서에는 한글 창제와 관련된 내용이 없다. 이때 세종대왕 문서가 포지티브 샘플, 나머지 세 문서가 네거티브 샘플이다.

정리하면 사용자 질의에 대한 정답 근거가 있는 문서가 포지티브 샘플, 검색은 되었지만 정답 근거가 없는 문서가 네거티브 샘플이다. 실제 서비스에서는 검색 결과에 질문과 무관한 문서가 섞이는 일이 잦다. 이때 대규모 언어 모델이 무관한 문서는 무시하고 관련 있는 문서만 참고해 답하는 능력을 갖추도록, RAFT 논문은 학습 데이터에 네거티브 샘플을 의도적으로 포함시킨다.

잘못된 예 — 검색 결과 중 포지티브 샘플만 골라 학습 데이터를 구성한다. 이렇게 학습한 모델은 실제 서비스에서 무관한 문서가 섞여 들어오면 그 내용까지 답변에 반영해 버릴 위험이 있다. 올바른 예 — 포지티브 샘플과 네거티브 샘플을 함께 넣어 학습시킨다. 모델이 "검색은 됐지만 답과 무관한 문서"를 구별하고 무시하는 법을 익힌다.

1.2 생각의 사슬

앞서 7장 §1에서 설명한 생각의 사슬(CoT) 기법은 결론을 바로 내는 대신 "문제 풀이 → 답" 순서로 프롬프트를 구성해 정답률을 높이는 방법이다. RAFT 논문은 다음과 같은 형식으로 이 기법을 사용한다(원문 그림 8-2, 일부 축약).

질문: 오베로이 가문은 본사가 어느 도시에 있는 호텔 회사의 일부입니까?
문맥: [오베로이 가문은 호텔 사업으로 유명한 인도 가문입니다. 주로 오베로이 그룹을 통해
      호텔 사업에 참여하고 있습니다 ... 오베로이 그룹은 본사가 델리에 있는 호텔 회사입니다.]
지시사항: 위의 질문, 문맥 및 답변을 고려하여 논리적 추론을 제공하십시오.
          답변 형식은 다음과 같이 해주십시오: ##이유: {이유} ##답변: {답변}

CoT 답변:
##이유: 문서에서 ##인용시작## 오베로이 가문은 호텔 사업으로 유명한 인도 가문입니다.
       주로 오베로이 그룹을 통해 호텔 사업에 참여하고 있습니다. ##인용종료## 는 오베로이
       가문이 오베로이 그룹에 참여하고 있음을 명시합니다. 그리고 문서에서 ##인용시작##
       오베로이 그룹은 본사가 델리에 있는 호텔 회사입니다. ##인용종료## 는 오베로이
       그룹의 본사가 델리에 있음을 명시합니다. 따라서, 오베로이 가문은 본사가 델리에
       있는 호텔 회사의 일부입니다.
##답변: 델리

프롬프트는 질문·문맥·지시사항 세 부분으로 구성된다. 문맥에는 검색된 문서가 들어가고, 지시사항은 답변 전에 반드시 이유를 먼저 작성하도록 강제한다. 여기서 핵심은 ##인용시작####인용종료## 표기다. 이 표기 사이의 문장은 반드시 문맥에 실제로 존재하는 원문을 그대로 인용해야 한다. 즉 모델은 자신이 알고 있는 지식이 아니라 주어진 문맥을 근거로만 답하도록 학습된다.

실제 검색 증강 생성에서는 검색 결과에 없는 내용을 모델이 자신의 지식으로 채워 잘못된 답변을 만드는 경우가 잦다. 답변 전에 원문 인용을 강제하는 것만으로도 이런 문제를 줄이고 검색 증강 생성의 성능을 끌어올릴 수 있다.

2. 성능 향상을 위한 팁

RAFT 논문에서 가져온 네거티브 샘플·생각의 사슬 외에, 실제 검색 증강 생성에서 발생할 수 있는 다양한 시나리오에 대응하기 위해 추가로 고려할 데이터 유형 두 가지를 살펴본다.

2.1 답변 없음 데이터

검색 결과가 항상 포지티브 샘플을 포함한다는 보장은 없다. 사용자가 가진 문서로는 답할 수 없는 질문을 던지거나 검색 성능이 낮아 관련 문서를 찾지 못하면, 검색된 문서 전부가 네거티브 샘플인 상황(원문 그림 8-3)이 생긴다.

이때 모델이 검색 문서에 없는 내용을 스스로 지어내 답하면 할루시네이션이 발생해 완전히 잘못된 답변이 나올 수 있다. 그래서 검색 결과가 전부 네거티브 샘플인 경우에는 "검색 결과를 찾을 수 없습니다"와 같이 답하도록 학습시켜, 모델이 근거 없이 자체적으로 답변하는 것을 막는다. 이 장의 실습 데이터셋도 이런 '답변 없음' 사례를 의도적으로 섞어 만든다.

잘못된 예 — 답변 없음 데이터 없이 학습한다. 모델이 모르는 질문에도 그럴듯한 문장을 만들어 답해 버리는 할루시네이션 위험이 남는다. 올바른 예 — "검색 결과에서 답을 찾을 수 없습니다" 같은 답변 없음 사례를 학습 데이터에 섞는다. 모델이 근거가 없을 때는 모른다고 답하는 법을 익힌다.

2.2 출처 인용

챗GPT에 '검색해서 알려줘'라는 문구를 붙이면 검색 기능을 사용해 답하며, 답변 문장마다 참고한 웹사이트 출처를 표기한다(원문 그림 8-4는 '서울에서 하는 24년 9월 행사를 검색해서 알려줘'라는 질의의 예시로, 사이트 3곳을 인용해 서울 오토 살롱(9월 6~8일, 자동차 튜닝·애프터마켓 전시)과 프리즈 서울(9월 초 현대미술 페어) 등을 답변에 나열하며 각 문장 옆에 출처를 남긴다. 첫째·넷째 항목의 행사명은 원문 캡처의 앞부분이 추출 과정에서 잘려 복원하지 않았다).

이렇게 답변마다 인용 출처를 남기면 사용자가 언제든 원문을 확인해 진위를 검토할 수 있어 신뢰할 수 있는 답변을 얻는다. 또한 모델 입장에서도 반드시 출처를 남기도록 학습시키면 잘못된 답변을 할 가능성이 줄어든다.

원문은 이미 검색 증강 생성에 파인튜닝되어 공개된 모델인 Orion-14B-Chat-RAG의 실제 사례를 보여준다. '노키즈존에 대한 설문조사 결과 정리해줘'라는 질의에 검색 결과 문서 4개(docs1~docs4)가 주어졌을 때, 이 모델은 다음과 같이 답한다(일부 축약).

노키즈존에 대한 여러 설문조사 결과, 전반적으로 찬성 의견이 우세했습니다.
2015년 7월 온라인 육아 커뮤니티 회원 3,650명을 대상으로 한 조사에서 73.2%가
찬성했습니다 ... [docs3].
2016년 11월 종합편성채널에서 500명의 일반 시민을 대상으로 한 조사에서는 64%가
찬성했습니다 ... [docs4].
서비스업 아르바이트생 1,120명을 대상으로 한 조사에서는 66.8%가 근무 중인 매장의
노키즈존 전환에 찬성했습니다 ... [docs2].

답변의 각 단락 끝에 [docs번호] 형태로 인용한 문서 번호를 남긴 것을 볼 수 있다. 이런 식으로 답변의 각 문장·단락이 어느 검색 문서에서 나왔는지 표기하도록 학습시키면 사용자 신뢰도뿐 아니라 잘못된 답변을 할 가능성을 줄이는 데도 도움이 된다.

3. RAG 학습 데이터셋 살펴보기

앞서 배운 개념(네거티브 샘플·생각의 사슬·답변 없음·출처 인용)을 반영해 실제로 만들어진 RAG 학습용 데이터셋을 살펴본다.

3.1 학습 데이터 소개

저자는 검색 증강 생성을 위한 학습 데이터를 허깅페이스에 공개했다 — iamjoon/klue-mrc-ko-rag-dataset. 허깅페이스 웹사이트에서 이 데이터셋에 접근하는 절차는 다음과 같다. 먼저 구글에서 '허깅페이스'를 검색해 공식 사이트(huggingface.co)에 접속한다. 사이트 위쪽 검색창에 데이터셋 이름 klue-mrc-ko-rag-dataset을 입력하면 해당 데이터셋의 뷰어 페이지로 이동한다.

참고 — 허깅페이스 데이터셋 뷰어 화면(원문 캡처)에는 question·search_result·answer·extracted_ref_numbers·type 다섯 개 열과 예시 행이 표 형태로 보인다. 이 화면 캡처의 표는 추출 과정에서 문자가 심하게 뒤섞여(OCR 손상) 그대로 옮기지 않았다 — 아래 §3.2의 실제 코드 실행 결과로 같은 내용을 확인한다.

3.2 학습 데이터 탐색

주피터 노트북(ch08_RAG_DATASET.ipynb)에서 필요한 라이브러리를 설치하고 데이터를 불러온다.

!pip install datasets
import numpy as np
import matplotlib.pyplot as plt
from datasets import load_dataset

dataset = load_dataset("iamjoon/klue-mrc-ko-rag-dataset")

# 데이터프레임으로 변환하고 실습에 필요한 열만 선택
df = dataset['train'].to_pandas()
df = df[['question', 'search_result', 'answer', 'extracted_ref_numbers', 'type']]
df.head()

선택한 다섯 개 열은 각각 question(질문), search_result(검색 결과), answer(최종 답변), extracted_ref_numbers(답변에서 인용된 문서 번호 목록), type(데이터 유형)이다. type 열의 고유값을 확인하면 다음 다섯 가지가 나온다.

print('데이터 타입 종류:', df['type'].unique())
데이터 타입 종류: ['mrc_question' 'mrc_question_with_1_to_4_negative'
                  'synthetic_question' 'paraphrased_question' 'no_answer']

각 유형의 특징은 다음과 같다(표 8-1).

type 내용
mrc_question 장소·이름·날짜 등을 묻는 지엽적인 질문. 검색 결과는 5개로 고정
mrc_question_with_1_to_4_negative 질문 형태는 mrc_question과 같은 지엽적 질문이지만, 검색 결과 개수가 1~4개 사이로 고르지 않다
paraphrased_question 지엽적인 질문이되 질문 형태가 완성된 문장이 아니라 명사구다. 검색 결과는 5개로 고정
synthetic_question 이유·장점·단점 등을 묻는 포괄적인 질문. 검색 결과는 5개로 고정이며 여러 문서를 동시에 인용하는 경우가 많다
no_answer 질문에 대한 답이 검색 결과에 없는 데이터. 답변은 반드시 "검색 결과에는 없다"고 안내해야 한다

이렇게 다양한 유형을 섞어 두는 이유는 실제 서비스에서 벌어질 수 있는 여러 상황 — 지엽적인 질문, 검색 결과 개수가 일정하지 않은 경우, 질문이 명사구인 경우, 여러 문서를 종합해야 하는 포괄적인 질문, 답이 아예 없는 경우 — 에 성능 저하 없이 대응하도록 학습시키기 위해서다.

실제로 mrc_question 유형 중 5번 샘플을 확인하면 다음과 같다.

print('5번 샘플의 타입:', df['type'].loc[5])
print('5번 샘플의 질문:', df['question'].loc[5])
print('5번 샘플의 답변:', df['answer'].loc[5])
print('5번 샘플의 답변에서 인용한 문서 번호:', df['extracted_ref_numbers'].loc[5])
print('5번 샘플의 검색 결과 개수:', len(df['search_result'].loc[5]))
5번 샘플의 타입: mrc_question
5번 샘플의 질문: 에티하드 웰니스 프로그램의 일환으로 위생에 관한 정보를 제공하는 것은 누구인가?
5번 샘플의 답변: 에티하드 웰니스 프로그램의 일환으로 위생에 관한 정보를 제공하는 사람은 특별
              훈련 과정을 거친 에티하드항공의 웰니스 엠버서더입니다 ... [[ref3]].
5번 샘플의 답변에서 인용한 문서 번호: [3]
5번 샘플의 검색 결과 개수: 5

답변 끝의 [[ref3]] 표기는 세 번째 검색 문서를 인용했다는 뜻이며, 실제로 search_result 열의 세 번째(인덱스 2) 문서에 위생 정보를 제공하는 주체(에티하드항공의 웰니스 엠버서더)가 언급되어 있다. 이 데이터는 답변에서 인용한 문서 출처를 [[ref문서번호]] 형식으로 표기하는 규칙을 따른다.

extracted_ref_len(인용 문서 개수)·search_result_len(검색 문서 개수) 열을 추가해 유형별 통계를 함수로 확인할 수도 있다.

df['search_result_len'] = df['search_result'].apply(len)
df['extracted_ref_len'] = df['extracted_ref_numbers'].apply(len)

def plot_lengths_by_type(df, plot_type):
    """특정 type의 extracted_ref_len·search_result_len 분포를 출력하고 시각화한다."""
    if plot_type not in df['type'].unique():
        print(f"'{plot_type}'는 유효하지 않은 타입입니다.")
        return
    df_type = df[df['type'] == plot_type]
    total_count = len(df_type)
    extracted_counts = df_type['extracted_ref_len'].value_counts().sort_index()
    search_counts = df_type['search_result_len'].value_counts().sort_index()
    print(f"타입: {plot_type}")
    print(f"전체 데이터 수: {total_count:,}개")
    for length, count in extracted_counts.items():
        print(f"{length}개의 문서를 인용한 샘플: {count}개")
    for length, count in search_counts.items():
        print(f"검색 문서로 {length}개가 존재하는 경우: {count}개")
    fig, axes = plt.subplots(1, 2, figsize=(5, 2.5))
    extracted_counts.plot(kind='bar', ax=axes[0], xlabel='extracted_ref_len', ylabel='Count', grid=True)
    search_counts.plot(kind='bar', ax=axes[1], xlabel='search_result_len', ylabel='Count', grid=True)
    plt.tight_layout()
    plt.show()

이 함수로 각 유형을 확인하면 유형별 성격이 뚜렷이 드러난다. mrc_question(전체 491개)은 439개(89%)가 문서 1개만 인용해 지엽적인 질문의 성격을 보여준다. synthetic_question(전체 497개)은 반대로 2개 이상 문서를 인용하는 샘플이 340개(약 68%)로, 포괄적인 질문일수록 여러 문서를 종합해야 함을 보여준다. mrc_question_with_1_to_4_negative(296개)는 검색 문서 개수 자체가 1~4개로 고르게 분포해, 검색 결과가 5개로 고정되지 않는 실제 상황에 대비한다. paraphrased_question(196개)은 mrc_question과 비슷하게 대부분(177개) 문서 1개만 인용한다. no_answer(404개)는 전부 인용 문서 0개로, 예외 없이 "찾을 수 없다"고 답해야 하는 데이터다.

4. 로컬 LLM Qwen 파인튜닝하기

대규모 언어 모델을 학습하려면 고성능 GPU가 필요하다. 이 절에서는 시간 단위로 과금되는 GPU 클라우드 런팟(RunPod)을 이용해 Qwen 모델을 LoRA 방식으로 파인튜닝하는 전 과정을 실습한다.

4.1 런팟을 이용한 실습 환경 설정

런팟에서 GPU 서버를 대여하는 절차는 다음과 같다.

  1. 구글에서 'runpod'을 검색하거나 RunPod 공식 사이트에 접속해 회원 가입한다.
  2. 왼쪽 메뉴 [Billing]에서 [Pay with Card]로 결제한다. 런팟의 최소 결제 금액은 10달러이며, 이번 장 실습에는 일반적으로 10달러면 충분하다. 시행착오를 고려한다면 20달러 이상을 권장한다.
  3. 왼쪽 메뉴 [Pods] → 화면 중앙 [+Deploy]를 클릭하면 대여 가능한 GPU 목록이 나온다. 여기서는 [A100 SXM]을 선택한다.
  4. GPU 개수를 조절할 수 있다. 예를 들어 2개를 선택하면 시간당 약 3.78달러(약 5,500원)가 과금된다. 이번 실습은 GPU 1개로 충분하므로 추가 과금이 없도록 1개로 되돌린다.
  5. [Edit Template]에서 [Container Disk] 값을 넉넉하게 50GB로 늘린다. 대규모 언어 모델과 데이터셋을 내려받을 때 저장 공간이 부족해지지 않도록 하기 위해서다.
  6. [Start Jupyter Notebook]을 체크하고 [Deploy On-Demand]를 클릭한다. 이 버튼을 누르는 순간부터 실시간으로 과금이 시작된다.
  7. Pod이 'Running' 상태가 되면 [Connect] → [Connect to Jupyter Lab (Port 8888)]을 클릭해 주피터 랩 환경으로 들어간다.
  8. 노트북을 새로 만들고 [File] → [Save Notebook As...]로 파일 이름을 ch08_RAG_TUNING.ipynb로 저장한다.

참고 — 그림 속 GPU 종류별 상세 요금표(A100 SXM·RTX A6000·A100 PCIe 등 여러 GPU의 시간당 가격)는 캡처 화면의 숫자가 문단마다 서로 어긋나 추출 손상으로 판단해 옮기지 않았다. 본문에 문장으로 명시된 값(GPU 2개 선택 시 시간당 약 3.78달러)만 인용한다. GPU 가격과 재고는 시점에 따라 달라지므로 실습 전 공식 사이트에서 현재 요금을 다시 확인한다(아래 최신 동향 참고).

4.2 데이터 전처리

필요한 라이브러리를 설치하고 도구를 임포트한다.

!pip install torch==2.4.0 transformers==4.45.1 datasets==3.0.1 accelerate==0.34.2 trl==0.11.1 peft==0.13.0
from datasets import load_dataset, Dataset
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer

각 도구의 역할은 다음과 같다. load_dataset·Dataset은 허깅페이스 데이터셋을 로드하고 다루는 데 쓰인다. AutoModelForCausalLM은 언어 모델을, AutoTokenizer는 그 모델 전용 토크나이저를 자동으로 불러온다. LoraConfig는 LoRA 학습에 필요한 설정값을 정의하고, SFTConfig는 학습률·배치 크기 등 학습 과정 전반을 설정하며, SFTTrainer가 실제 학습을 수행한다.

데이터셋을 불러오고 시스템 프롬프트를 정의한다.

# 허깅페이스 허브에서 데이터셋 로드
dataset = load_dataset("iamjoon/klue-mrc-ko-rag-dataset", split="train")

# system_message 정의
system_message = """당신은 검색 결과를 바탕으로 질문에 답변해야 합니다.
다음의 지시사항을 따르십시오.
1. 질문과 검색 결과를 바탕으로 답변하십시오.
2. 검색 결과에 없는 내용을 답변하려고 하지 마십시오.
3. 질문에 대한 답이 검색 결과에 없다면 "해당 질문에 대한 내용이 없습니다."라고 답변하십시오.
4. 답변할 때 특정 문서를 참고하여 문장 또는 문단을 작성했다면 뒤에 출처를 이중 리스트로
   남기십시오. 예를 들어 1번 문서에서 인용했다면 뒤에 [[ref1]]이라고 기재하십시오.
5. 여러 문서를 동시에 인용했다면 [[ref1]], [[ref5]]처럼 모두 기재하십시오.
6. 최대한 다수의 문서를 인용하여 답변하십시오.
검색 결과:
{search_result}"""

이 시스템 프롬프트는 §2에서 다룬 답변 없음 처리(지시 3)와 출처 인용(지시 4·5)을 그대로 학습 데이터에 심는다. 이어서 type별로 균형을 유지하며 학습/테스트 데이터를 나누고, 각 샘플을 OpenAI 형식(messages 리스트에 system/user/assistant 역할을 담는 구조)으로 변환한다.

test_ratio = 0.8
train_data = []
test_data = []

for type_name in set(dataset['type']):
    # 현재 type에 해당하는 데이터의 인덱스만 추출
    curr_type_data = [i for i in range(len(dataset)) if dataset[i]['type'] == type_name]
    # test_ratio에 따라 test 데이터 개수 계산
    test_size = int(len(curr_type_data) * test_ratio)
    test_data.extend(curr_type_data[:test_size])
    train_data.extend(curr_type_data[test_size:])

def format_data(sample):
    # 검색 결과를 문서1, 문서2... 형태로 포매팅
    search_result = "\n-----\n".join(
        f"문서{idx + 1}: {result}" for idx, result in enumerate(sample["search_result"])
    )
    return {
        "messages": [
            {"role": "system", "content": system_message.format(search_result=search_result)},
            {"role": "user", "content": sample["question"]},
            {"role": "assistant", "content": sample["answer"]},
        ]
    }

train_dataset = [format_data(dataset[i]) for i in train_data]
test_dataset = [format_data(dataset[i]) for i in test_data]

print(f"전체 데이터 분할 결과: Train {len(train_dataset)}개, Test {len(test_dataset)}개")
원본 데이터의 type 분포:
synthetic_question: 497
mrc_question: 491
mrc_question_with_1_to_4_negative: 296
paraphrased_question: 196
no_answer: 404

전체 데이터 분할 결과: Train 380개, Test 1504개

test_ratio = 0.8은 전체 데이터의 80%를 테스트용으로, 20%를 학습용으로 쓴다는 뜻이다. 일반적으로는 학습 데이터를 더 많이 두지만, 이번 실습은 유료 클라우드를 사용하므로 학습 비용을 아끼기 위해 학습 데이터를 의도적으로 적게 잡았다. type별로 따로 나누는 이유는, 무작위로 전체를 나누면 특정 유형이 학습 데이터 한쪽으로 쏠려 모델이 그 유형에만 강해지는 편향이 생길 수 있기 때문이다.

마지막으로 리스트 상태인 데이터를 datasets.Dataset 객체로 바꾼다.

train_dataset = Dataset.from_list(train_dataset)
test_dataset = Dataset.from_list(test_dataset)

4.3 Qwen 템플릿 이해하기

모델과 토크나이저를 로드한다.

model_id = "Qwen/Qwen2-7B-Instruct"
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    device_map="auto",
    torch_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained(model_id)

원문은 Qwen2-7B-Instruct를 중국 기업이 공개한 한국어 성능이 뛰어난 모델로 소개하며, 책 집필 시점(2025) 기준으로 한국어 성능이 좋은 다른 모델로 메타(Meta)의 LLaMA 3.1~3.3 계열, 구글(Google)의 Gemma 2~3 계열을 함께 언급한다(모델 세대는 계속 갱신되므로 아래 최신 동향 참고).

로드한 모델은 사전학습 때 익힌 고유한 챗 템플릿을 갖는다. 챗 템플릿은 대화의 역할(system/user/assistant)을 구분 토큰으로 감싸는 형식이며, 파인튜닝 데이터도 반드시 이 형식을 그대로 따라야 한다. 학습 때 사용한 템플릿과 다른 형식으로 데이터를 가공하면, 모델이 문맥을 제대로 인식하지 못해 성능이 떨어질 수 있다. Qwen은 다음 형식을 따른다.

<|im_start|>system
시스템 프롬프트<|im_end|>
<|im_start|>user
사용자 프롬프트(사용자의 질문)<|im_end|>
<|im_start|>assistant
대규모 언어 모델이 해야 하는 답변<|im_end|>

apply_chat_template()에 OpenAI 형식으로 가공된 데이터를 넣으면 이 템플릿 형식으로 변환된다.

text = tokenizer.apply_chat_template(
    train_dataset[0]["messages"], tokenize=False, add_generation_prompt=False
)
print(text)
<|im_start|>system
당신은 검색 결과를 바탕으로 질문에 답변해야 합니다.
...
<|im_end|>
<|im_start|>user
기후 변화가 농업에 미치는 영향은 무엇인가요?<|im_end|>
<|im_start|>assistant
기후 변화는 농업에 다양한 영향을 미치고 있습니다 ... [[ref4]].
...<|im_end|>

4.4 로라 학습을 위한 설정값

LoRA(Low-Rank Adaptation)는 풀 파인튜닝(모델 전체 가중치를 업데이트)보다 연산량과 GPU 메모리 사용량을 줄여 학습하는 방법이다. 기존 모델의 가중치는 그대로 두고, 추가로 작은 저차원 행렬을 덧붙여 그 행렬만 학습한다. 그 결과 학습 속도가 빨라지고, 많은 GPU 자원이 없어도 파인튜닝이 가능해진다.

peft_config = LoraConfig(
    lora_alpha=32,
    lora_dropout=0.1,
    r=8,
    bias="none",
    target_modules=["q_proj", "v_proj"],
    task_type="CAUSAL_LM",
)
  • lora_alpha — LoRA 학습 결과가 기존 모델 예측에 얼마나 강하게 반영될지를 정한다. 값이 클수록 학습한 정보가 강하게 반영되고, 작을수록 기존 모델의 특성이 더 유지된다.
  • lora_dropout — 학습 중 일부 정보를 의도적으로 제외해(여기서는 약 10%) 특정 데이터에 과도하게 맞춰지는 것을 막는다.
  • r — 학습할 정보의 크기(랭크)를 정한다. 값이 클수록 더 많은 정보를 학습하지만 메모리·연산량이 늘고, 작을수록 메모리는 절약되지만 학습 범위가 줄어든다.
  • bias — 편향값까지 학습할지를 정한다. "none"은 기존 편향값을 그대로 두고, "all"은 편향값까지 LoRA로 조정해 더 큰 변화를 준다.
  • target_modules — LoRA를 적용할 부분을 고른다. q_proj·v_proj는 모델이 입력을 처리할 때 중요한 역할을 하는 부분이다.
  • task_type — 모델의 작업 유형을 지정한다. "CAUSAL_LM"은 입력을 바탕으로 다음 단어를 예측하는, Qwen과 같은 대규모 언어 모델에 적합하다.

잘못된 예target_modules를 지정하지 않고 모델 전체 가중치를 그대로 학습한다(사실상 풀 파인튜닝). GPU 메모리 요구량이 커지고 LoRA의 장점(빠른 학습·적은 자원)을 잃는다. 올바른 예 — 위 코드처럼 q_proj·v_proj같이 필요한 부분만 target_modules로 지정해 적은 자원으로 조정한다.

4.5 학습을 위한 설정값

SFTConfig는 LoRA와 별개로 학습 과정 전반(에포크 수·배치 크기·저장 주기 등)을 설정한다.

args = SFTConfig(
    output_dir="qwen2-7b-rag-ko",                       # 저장될 디렉터리
    num_train_epochs=3,                                  # 학습할 총 에포크 수
    per_device_train_batch_size=2,                       # GPU당 배치 크기
    gradient_accumulation_steps=2,                       # 그래디언트 누적 스텝 수
    gradient_checkpointing=True,                         # 메모리 절약을 위한 체크포인팅
    optim="adamw_torch_fused",                           # 최적화기
    logging_steps=10,                                    # 로그 기록 주기
    save_strategy="steps",                               # 저장 전략
    save_steps=50,                                       # 저장 주기
    bf16=True,                                           # bfloat16 사용
    learning_rate=1e-4,                                  # 학습률
    max_grad_norm=0.3,                                   # 그래디언트 클리핑
    warmup_ratio=0.03,                                   # 워밍업 비율
    lr_scheduler_type="constant",                        # 고정 학습률
    push_to_hub=False,                                   # 허브 업로드 안 함
    remove_unused_columns=False,
    dataset_kwargs={"skip_prepare_dataset": True},
    report_to=None,
)

배치를 여러 개 처리한 뒤 한 번에 모델을 업데이트하는 gradient_accumulation_steps는 GPU 메모리가 부족할 때 작은 배치를 여러 번 모아 큰 배치처럼 학습하도록 돕는다. gradient_checkpointing=True는 일부 계산 결과를 저장하지 않고 필요할 때 다시 계산해 메모리를 아끼는 대신 속도가 조금 느려진다. learning_rate(학습률)가 너무 크면 학습이 불안정해지고 너무 작으면 학습이 느려지므로, warmup_ratio로 초반 3% 구간 동안 학습률을 서서히 올려 안정적으로 시작하게 한다. save_strategy·save_steps는 50스텝마다 모델을 저장해, 학습 도중 문제가 생겨도 처음부터 다시 학습하지 않도록 돕는다. bf16=True는 메모리를 아끼면서도 연산 정확도를 유지하는 bfloat16 형식을 사용한다는 뜻이다.

4.6 정수 인코딩

모델에 데이터를 전달하려면 텍스트를 정수로 바꾸는 인코딩 과정을 거친다. 학습에는 입력(input_ids)과 정답(labels) 두 가지가 필요하다. 예를 들어 다음처럼 짧은 대화를 챗 템플릿으로 감싸고 인코딩한다고 하자.

<|im_start|>system
당신은 친절한 AI 어시스턴트입니다.<|im_end|>
<|im_start|>user
안녕하세요, 오늘 날씨는 어떤가요?<|im_end|>
<|im_start|>assistant
안녕하세요! 오늘 날씨는 맑고 화창합니다.<|im_end|>

input_ids는 이 전체 텍스트를 토크나이저로 인코딩한 정수 시퀀스다. 각 토큰이 어떤 정수로 매핑되는지는 모델마다 다르므로 반드시 그 모델의 토크나이저를 사용해야 한다(예: Qwen에서 <|im_start|>는 151644다).

핵심은 labels를 만드는 방식이다. input_ids에는 시스템·사용자 프롬프트와 모델의 답변이 모두 들어 있지만, 실제로 모델이 학습해야 하는 것은 "주어진 프롬프트를 보고 적절한 답변을 생성하는 것"뿐이다. 그래서 모델이 직접 생성할 필요가 없는 부분(시스템·사용자 프롬프트)은 labels에서 -100으로 채워 학습 대상에서 제외하고, assistant가 답한 부분만 input_ids의 값을 그대로 복사한다. -100은 PyTorch가 손실 계산에서 무시하도록 정한 값이다.

def collate_fn(batch):
    new_batch = {"input_ids": [], "attention_mask": [], "labels": []}
    for example in batch:
        # messages의 각 내용에서 개행문자 제거
        clean_messages = [
            {"role": m["role"], "content": m["content"]} for m in example["messages"]
        ]
        # 깨끗해진 메시지로 템플릿 적용
        text = tokenizer.apply_chat_template(
            clean_messages, tokenize=False, add_generation_prompt=False
        ).strip()
        tokenized = tokenizer(
            text, truncation=True, max_length=max_seq_length, padding=False, return_tensors=None
        )
        input_ids = tokenized["input_ids"]
        attention_mask = tokenized["attention_mask"]
        labels = [-100] * len(input_ids)

        im_start_tokens = tokenizer.encode("<|im_start|>", add_special_tokens=False)
        im_end_tokens = tokenizer.encode("<|im_end|>", add_special_tokens=False)
        assistant_tokens = tokenizer.encode("assistant", add_special_tokens=False)

        i = 0
        while i < len(input_ids):
            # <|im_start|>assistant 구간을 찾는다
            if (i + len(im_start_tokens) <= len(input_ids) and
                    input_ids[i:i+len(im_start_tokens)] == im_start_tokens):
                assistant_pos = i + len(im_start_tokens)
                if (assistant_pos + len(assistant_tokens) <= len(input_ids) and
                        input_ids[assistant_pos:assistant_pos+len(assistant_tokens)] == assistant_tokens):
                    current_pos = assistant_pos + len(assistant_tokens)
                    # <|im_end|>를 찾을 때까지 labels에 실제 값을 채운다
                    while current_pos < len(input_ids):
                        if (current_pos + len(im_end_tokens) <= len(input_ids) and
                                input_ids[current_pos:current_pos+len(im_end_tokens)] == im_end_tokens):
                            for j in range(len(im_end_tokens)):
                                labels[current_pos + j] = input_ids[current_pos + j]
                            break
                        labels[current_pos] = input_ids[current_pos]
                        current_pos += 1
                    i = current_pos
            i += 1

        new_batch["input_ids"].append(input_ids)
        new_batch["attention_mask"].append(attention_mask)
        new_batch["labels"].append(labels)

    # 패딩 적용
    max_length = max(len(ids) for ids in new_batch["input_ids"])
    for i in range(len(new_batch["input_ids"])):
        padding_length = max_length - len(new_batch["input_ids"][i])
        new_batch["input_ids"][i].extend([tokenizer.pad_token_id] * padding_length)
        new_batch["attention_mask"][i].extend([0] * padding_length)
        new_batch["labels"][i].extend([-100] * padding_length)

    for k, v in new_batch.items():
        new_batch[k] = torch.tensor(v)
    return new_batch

collate_fn<|im_start|>assistant로 시작해 <|im_end|>가 나오기까지의 구간만 찾아 그 부분의 토큰만 labels에 복사하고 나머지는 전부 -100으로 채운다. 이렇게 하면 모델은 assistant의 응답을 생성하는 법만 학습하고, 이미 주어지는 시스템·사용자 프롬프트를 다시 생성하는 법은 학습하지 않는다.

max_seq_length = 8192  # 최대 8192개 토큰까지만 사용
example = train_dataset[0]
batch = collate_fn([example])
print(batch["input_ids"][0].tolist())
print(batch["labels"][0].tolist())
입력에 대한 정수 인코딩 결과: [151644, 8948, 198, 64795, 82528, 33704, ... (이하 생략) ...]
레이블에 대한 정수 인코딩 결과: [-100, -100, -100, -100, -100, -100, ... (이하 생략) ...]

두 결과의 길이는 같지만, labels는 실제 답변 부분을 제외하고 전부 -100으로 채워진 것을 확인할 수 있다. 전처리가 끝났으면 SFTTrainer로 실제 학습을 시작한다.

trainer = SFTTrainer(
    model=model,
    args=args,
    max_seq_length=max_seq_length,
    train_dataset=train_dataset,
    data_collator=collate_fn,
    peft_config=peft_config,
)
trainer.train()
trainer.save_model()

원문 기준 약 30분 정도의 학습 시간이 걸렸다.

잘못된 예 — 시스템·사용자 프롬프트까지 labels에 그대로 남겨 둔다. 모델이 이미 주어지는 입력 문장을 다시 생성하는 법까지 학습하게 되어, 정작 중요한 assistant 응답 학습에 쓰이는 유효 신호가 흐려진다. 올바른 예 — 위 collate_fn처럼 assistant 응답 구간만 남기고 나머지는 -100으로 마스킹한다.

4.7 모델 테스트하기

학습이 끝나면 output_dir(qwen2-7b-rag-ko) 아래에 checkpoint-50부터 시작해 save_steps=50 간격으로 체크포인트 디렉터리가 쌓인다. 학습이 끝나는 마지막 스텝에서도 저장되는데, 이 실습에서는 그 최종 저장 지점이 checkpoint-285다. 이 모델이 가장 많은 학습을 거친 버전이라 이후 테스트에 쓰지만, 원문은 "가장 많이 학습되었다고 성능이 가장 좋다는 보장은 없으며, 모든 checkpoint에 대해 테스트하고 평가해 보는 것이 바람직하다"고 덧붙인다.

테스트 데이터에서 모델의 입력과 정답을 분리한다.

prompt_lst = []
label_lst = []
for prompt in test_dataset["messages"]:
    text = tokenizer.apply_chat_template(prompt, tokenize=False, add_generation_prompt=False)
    input_text = text.split('<|im_start|>assistant')[0] + '<|im_start|>assistant'
    label = text.split('<|im_start|>assistant')[1]
    prompt_lst.append(input_text)
    label_lst.append(label)

기본 모델(파인튜닝 전)을 호출해 예측을 확인한다.

import torch
from peft import AutoPeftModelForCausalLM
from transformers import AutoTokenizer, pipeline

base_model_id = "Qwen/Qwen2-7B-Instruct"
model = AutoModelForCausalLM.from_pretrained(base_model_id, device_map="auto", torch_dtype=torch.float16)
pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)
eos_token = tokenizer("<|im_end|>", add_special_tokens=False)["input_ids"][0]

def test_inference(pipe, prompt):
    outputs = pipe(prompt, max_new_tokens=1024, eos_token_id=eos_token, do_sample=False)
    return outputs[0]['generated_text'][len(prompt):].strip()

prompt = prompt_lst[42]
label = label_lst[42]
print(f"모델의 예측:\n{test_inference(pipe, prompt)}")
print(f"정답:\n{label}")

42번 샘플('정부와 기업 간 협업의 사례와 그 결과는 무엇인가요?')에 대해, 기본 모델은 사례를 요약해 답하지만 [[ref문서번호]] 출처 표기를 전혀 남기지 않고 답변 길이도 짧은 편이다. 이어서 파인튜닝된 모델을 호출한다.

peft_model_id = "qwen2-7b-rag-ko/checkpoint-285"
fine_tuned_model = AutoPeftModelForCausalLM.from_pretrained(
    peft_model_id, device_map="auto", torch_dtype=torch.float16
)
pipe = pipeline("text-generation", model=fine_tuned_model, tokenizer=tokenizer)
prompt = prompt_lst[42]
label = label_lst[42]
print(f"모델의 예측:\n{test_inference(pipe, prompt)}")
print(f"정답:\n{label}")

파인튜닝 후 모델은 같은 질문에 대해 정답과 마찬가지로 소양강댐 건설, 구로동 수출공업단지, (주)이주의 사례를 각각 [[ref5]]·[[ref5]]·[[ref2]] 형식으로 출처를 표기하며 답한다. 원문은 "파인튜닝 후의 모델은 답변이 전반적으로 정돈되고, [[ref 문서 번호]]를 지키라는 지시사항도 충실히 이행한다"고 평가한다. 이처럼 파인튜닝을 통해 RAG에 사용하는 대규모 언어 모델의 답변 성능을 높일 수 있다.

4.8 GPU 종료하기

실습을 마쳤다면 반드시 GPU 서버를 반납해야 한다. 런팟 사이트에서 [Pods] 화면으로 이동해 정지 버튼을 누르면 [Stop Pod]로 서버를 중단할 수 있다. 다만 Stop은 GPU 사용만 멈출 뿐 볼륨 보관 비용은 계속 과금된다(하루 약 0.133달러 수준). 과금을 완전히 멈추려면 휴지통 모양 버튼으로 Pod을 Terminate(완전 삭제)해야 한다. Terminate는 되돌릴 수 없으며 /workspace에 저장하지 않은 데이터는 모두 사라진다.

핵심 개념 정리

개념 한 줄 설명
RAFT 검색 결과 중 정답 근거 문서(포지티브)와 무관 문서(네거티브)를 함께 넣고, 인용 기반 생각의 사슬로 답하도록 학습시켜 도메인 특화 RAG 성능을 높이는 파인튜닝 방법론
네거티브 샘플 / 포지티브 샘플 정답 근거가 없는 검색 문서(네거티브) vs 정답 근거가 있는 검색 문서(포지티브)
생각의 사슬(CoT) 결론 전에 "이유 → 답" 순서로 추론을 드러내는 프롬프트 기법. RAFT는 이유 작성 시 원문 인용을 강제한다
답변 없음 데이터 검색 결과에 정답이 없을 때 "찾을 수 없다"고 답하도록 학습시켜 할루시네이션을 막는 데이터
출처 인용 답변 문장마다 근거 문서 번호([[refN]])를 남기게 학습시켜 신뢰성과 검증 가능성을 높이는 기법
LoRA 기존 가중치는 고정하고 저차원 행렬만 추가로 학습해 GPU 메모리·연산량을 줄이는 파인튜닝 방식
챗 템플릿 모델이 사전학습 때 익힌 대화 형식(예: Qwen의 <|im_start|>...<|im_end|>). 파인튜닝 데이터도 이 형식을 따라야 한다
labels의 -100 손실 계산에서 제외할 토큰 표시값. 시스템·사용자 프롬프트는 -100, assistant 응답만 실제 토큰 id
SFTConfig 학습률·배치 크기·에포크 등 지도학습 파인튜닝 전반의 학습 설정을 정의하는 도구
런팟(RunPod) 시간당 과금되는 GPU 클라우드. 실습 후 반드시 Terminate해야 과금이 완전히 멈춘다

실무 체크리스트

  • [ ] 학습 데이터에 네거티브 샘플(무관 문서)을 포함시켰는가?
  • [ ] "답을 찾을 수 없습니다" 같은 답변 없음 데이터를 학습 데이터에 넣었는가?
  • [ ] 답변마다 인용 문서 번호를 남기도록 시스템 프롬프트에 명시했는가?
  • [ ] 검색 결과 개수가 항상 고정값(예: 5개)인 데이터만으로 학습하지는 않았는가?
  • [ ] 사용하는 모델의 챗 템플릿 형식을 확인하고 그 형식대로 데이터를 가공했는가?
  • [ ] labels에서 시스템·사용자 프롬프트 구간을 -100으로 마스킹했는가?
  • [ ] LoRA의 r·lora_alpha·target_modules를 GPU 메모리와 학습 목표에 맞게 정했는가?
  • [ ] 학습 전후 모델을 같은 테스트 샘플로 비교해 성능 향상을 확인했는가?
  • [ ] 실습이 끝난 뒤 GPU 서버를 Stop이 아니라 Terminate까지 해서 과금을 완전히 멈췄는가?

연습문제

  1. 유형. 사내 RAG 챗봇이 검색 결과에 없는 내용도 그럴듯하게 지어내 답하는 문제가 발생했다. RAFT 논문의 개념을 참고해 이 문제를 완화할 수 있는 학습 데이터 구성 방법을 설명하라.
  2. 파인튜닝용 데이터셋의 검색 결과 개수가 모든 샘플에서 5개로 고정되어 있다. 실제 서비스에서는 검색 결과가 2~3개만 나오는 경우도 많다면 어떤 문제가 생길 수 있고, 데이터셋을 어떻게 보완해야 하는가?
  3. Qwen이 아닌 다른 대규모 언어 모델로 같은 데이터셋을 파인튜닝하려고 한다. 이 장의 코드 중 챗 템플릿과 관련해 반드시 다시 확인해야 하는 부분은 무엇이며, 그 이유는 무엇인가?
  4. LoRA 설정에서 r 값을 8에서 64로 올리면 어떤 효과와 트레이드오프가 발생하는가?

최신 동향 (2026-09 기준)

최신 동향 (검증 2026-09-12) — 책이 사용한 라이브러리·모델 버전은 집필 시점(2025) 기준이며, 파인튜닝의 개념과 코드 구조(LoRA·SFTTrainer·챗 템플릿 처리)는 그대로 유효하다. 아래는 실습 전 확인이 필요한 변경 사항이다.

  • 이 장은 Qwen2-7B-Instruct로 실습한다. Qwen 계열은 이후 여러 세대를 거쳐 Qwen3 계열이 공개됐고, 2026년에는 Qwen3.5·Qwen3.6·Qwen3.7·Qwen3.8까지 이어졌다(Qwen 공식 릴리스 노트). 최신 한국어 성능이 필요하면 model_id를 그 시점의 최신 Qwen 계열로 바꾸는 것을 고려하되, 파라미터 수·VRAM 요구량이 달라지므로 §4.1의 GPU 대여 사양도 함께 조정해야 한다.
  • 파인튜닝에 쓰는 peft·trl 라이브러리는 책이 고정한 버전(peft==0.13.0·trl==0.11.1)보다 여러 세대 앞서 있다. 최신 버전에서는 일부 인자명·클래스 동작이 바뀔 수 있으므로, 그대로 재현하려면 책이 명시한 버전을 그대로 설치하고, 최신 기능이 필요하면 공식 문서에서 변경 사항을 먼저 확인한다.
  • RunPod은 이 확인 시점에도 서비스 중인 GPU 클라우드이지만, GPU 종류별 시간당 요금과 재고는 수시로 바뀐다. 이 장의 가격(예: A100 SXM 1개 대여 시 GPU 개수별 시간당 요금)은 집필 시점의 스냅샷이므로, 실습 전 RunPod 사이트에서 현재 요금을 다시 확인한다.

부록 A. 핵심 비교표

구분 풀 파인튜닝 LoRA
학습 대상 모델 전체 가중치 추가로 붙인 저차원 행렬만
GPU 메모리 많이 필요 상대적으로 적게 필요
학습 속도 느림 빠름
기존 지식 보존 전체 가중치가 바뀌어 손실 위험 원래 가중치를 고정해 보존에 유리
구분 네거티브 샘플 포지티브 샘플
정의 검색은 됐지만 정답 근거가 없는 문서 정답 근거가 들어 있는 문서
학습 목적 모델이 무시하는 법을 배우게 한다 모델이 인용하는 법을 배우게 한다
구분 LLM 파인튜닝(8장) 임베딩 모델 파인튜닝(9장)
학습 대상 답변을 생성하는 대규모 언어 모델 문서를 벡터로 바꾸는 임베딩 모델
목표 검색된 결과를 바탕으로 더 정확히 답하기 질문과 관련 문서가 더 가깝게 검색되도록 하기

부록 B. 추천 참고 자료

외부 자료 (Tier 1 공식, 생존 확인 2026-09-12)

본 책 연계 챕터

챕터 이 장이 다루지 않은 것
9장 §1 임베딩 모델 학습의 원리 — 이 장은 답변을 생성하는 LLM만 다루고, 검색에 쓰이는 임베딩 모델을 파인튜닝하는 방법은 다루지 않는다
4장 §4 문서 후처리(리랭킹) — 파인튜닝 없이 크로스 인코더 등으로 검색 순위만 재조정해 성능을 올리는 대안적 방법

부록 C. 연습문제 풀이

  1. (문제 1 정답) 검색 결과에 없는 내용을 지어내는 문제는 RAFT의 두 가지 개념으로 완화할 수 있다. 첫째, 학습 데이터에 검색 결과가 전부 네거티브 샘플인 '답변 없음' 사례를 섞어, 근거가 없을 때는 "검색 결과에서 찾을 수 없습니다"라고 답하도록 학습시킨다. 둘째, 시스템 프롬프트에 "검색 결과에 없는 내용을 답변하려고 하지 마십시오"라는 지시와 출처 인용([[refN]]) 규칙을 명시해, 답변이 항상 검색 결과에 근거하도록 강제한다.
  2. 검색 결과가 5개로 고정된 데이터로만 학습하면, 실제 서비스에서 검색 결과가 2~3개만 나오는 상황에서 모델이 제대로 대응하지 못할 수 있다(원문의 mrc_question_with_1_to_4_negative 유형이 바로 이 문제를 겨냥해 만들어졌다). 데이터셋에 검색 결과 개수가 1~4개 등으로 고르지 않은 샘플을 섞어, 검색 결과 개수가 달라져도 안정적으로 답하도록 보완해야 한다.
  3. 챗 템플릿과 관련해 다시 확인해야 하는 부분은 <|im_start|>·<|im_end|>·assistant 같은 구분 토큰 문자열과, collate_fn에서 이 토큰들을 인코딩해 labels를 채우는 로직이다. 각 모델은 사전학습 때 익힌 챗 템플릿 형식이 다르므로(예: 다른 모델은 다른 구분 토큰을 쓸 수 있다), tokenizer.apply_chat_template()이 그 모델의 토크나이저 설정을 그대로 따르는지, collate_fn의 토큰 매칭 로직이 새 모델의 템플릿 형식과 맞는지 확인해야 한다. 형식이 어긋나면 모델이 assistant 응답 구간을 제대로 학습하지 못한다.
  4. r 값을 8에서 64로 올리면 LoRA가 학습할 수 있는 정보의 양(저차원 행렬의 크기)이 늘어나 더 복잡한 패턴을 학습할 여지가 커진다. 그 대신 학습 가능한 파라미터 수가 늘어 GPU 메모리 사용량과 연산량이 증가하고, 학습 속도가 느려진다. 데이터가 많고 복잡한 패턴을 학습해야 할수록 큰 r이 유리하지만, 데이터가 적을 때 r을 지나치게 키우면 과적합 위험도 함께 커진다.
난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플   P 음성 버전 2026-08-05b